本系列文已出版成書「NestJS 基礎必學實務指南:使用強大且易擴展的 Node.js 框架打造網頁應用程式」,感謝 iT 邦幫忙與博碩文化的協助。如果對 NestJS 有興趣、覺得這個系列文對你有幫助的話,歡迎前往購書,你的支持是我最大的寫作動力!
這個系列文即將進入尾聲,是時候來驗收一下前面所學到的東西了,雖然不會所有的功能都在此次實戰演練中使用到,但我會盡量把一些我覺得很實用且常用的功能都納入考量,那就廢話不多說趕快開始吧!
這次的實戰演練會做一個簡單的「TodoList」,這個 TodoList 擁有基本的角色權限管理,並會有兩大資源,分別是:使用者 (user) 與 待辦事項 (todo)。而角色共會分成 系統管理者(admin)、管理員 (manager) 以及 成員 (member),他們各自擁有的操作權限如下:
專案架構預計會採用下方的分類方式來進行,這裡僅列出重點項目:
.
├─ .env
├─ src
| ├─ common/
| ├─ configs/
| ├─ core/
| ├─ features/
| ├─ app.module.ts
| └─ main.ts
└─ rbac
├─ model.conf
└─ policy.csv
.env
:環境變數配置檔。src/common
:放一些共用的項目,如:constants
、enums
、models
等。src/configs
:放環境變數相關的工廠函式。src/core
:放一些與應用程式本身較有直接關聯的元件,如:guards
、interceptors
、pipes
等。src/features
:主要功能放在這裡,像是這次會用到的 user
、todo
、auth
等。src/app.module.ts
:根模組。src/main.ts
:載入點。rbac
:放置 Casbin 使用到的 model
與 policy
。首先,透過 CLI 快速建立一個空白專案:
$ nest new <PROJECT_NAME>
接著,將我們會用到的相關套件透過 npm
進行安裝:
$ npm install @nestjs/config // 環境變數模組
$ npm install @nestjs/mapped-types // DTO 映射型別技巧用
$ npm install @nestjs/mongoose mongoose // 與 MongoDB 互動用
$ npm install @nestjs/passport passport // 身分驗證模組
$ npm install @nestjs/jwt passport-jwt // JWT 與它的驗證策略
$ npm install @types/passport-jwt -D // passport-jwt 的型別定義
$ npm install passport-local // 本地身分驗證策略
$ npm install @types/passport-local -D // 本地身分驗證策略的型別定義
$ npm install casbin // 授權套件
$ npm install class-validator class-transformer // DTO 使用的裝飾器
在開發過程中,我們會需要將 MongoDB 相關的敏感資訊以及 JWT 密鑰放在環境變數,所以在 .env
檔案裡進行配置:
MONGO_USERNAME=<YOUR_USERNAME>
MONGO_PASSWORD=<YOUR_PASSWORD>
MONGO_RESOURCE=<YOUR_RESOURCE>
JWT_SECRET=<YOUR_JSW_SECRET_KEY>
提醒:詳細環境變數之配置可以參考 DAY16 - Configuration。
我們可以先將 MongooseModule
在 AppModule
做配置,運用工廠函式配置環境變數命名空間的技巧,將 MongoDB 的相關環境變數用 mongo
這個命名空間群組在一起。在 configs
資料夾底下新增 mongo.config.ts
:
import { registerAs } from '@nestjs/config';
export default registerAs('mongo', () => {
const username = process.env.MONGO_USERNAME;
const password = encodeURIComponent(process.env.MONGO_PASSWORD);
const resource = process.env.MONGO_RESOURCE;
const uri = `mongodb+srv://${username}:${password}@${resource}?retryWrites=true&w=majority`;
return { username, password, resource, uri };
});
接著,在 AppModule
引入 ConfigModule
並進行相關配置,再將 MongoDB 需要用到的環境變數帶入 MongooseModule
中,進而建立連線:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import mongoConfigFactory from './configs/mongo.config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [mongoConfigFactory],
}),
MongooseModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.get<string>('mongo.uri'),
useFindAndModify: false,
}),
}),
],
})
export class AppModule {}
提醒:詳細 mongoose 的使用方法可以參考 DAY22 - MongoDB,工廠函式配置環境變數命名空間可以參考 DAY16 - Configuration。
我們在實作身分驗證時會使用到 JWT,我們可以先把需要使用到的密鑰透過 secrets
這個命名空間來群組在一起。在 configs
資料夾中新增 secret.config.ts
:
import { registerAs } from '@nestjs/config';
export default registerAs('secrets', () => {
const jwt = process.env.JWT_SECRET;
return { jwt };
});
接著,調整在 AppModule
中的 ConfigModule
,多添加一個工廠函式在 load
中:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import mongoConfigFactory from './configs/mongo.config';
import secretConfigFactory from './configs/secret.config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [mongoConfigFactory, secretConfigFactory],
}),
MongooseModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.get<string>('mongo.uri'),
useFindAndModify: false,
}),
}),
],
})
export class AppModule {}
可以透過 Pipe 幫助 API 進行型別檢查,這裡可以運用 ValidationPipe
配置在全域的技巧來達成,我們只需要修改 AppModule
即可,在 providers
中運用自訂 Provider 的技巧來進行配置,provide
指定為 APP_PIPE
,而 useClass
指定為 ValidationPipe
:
import { APP_PIPE } from '@nestjs/core';
import { Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import mongoConfigFactory from './configs/mongo.config';
import secretConfigFactory from './configs/secret.config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [mongoConfigFactory, secretConfigFactory],
}),
MongooseModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.get<string>('mongo.uri'),
useFindAndModify: false,
}),
}),
],
providers: [
{
provide: APP_PIPE,
useClass: ValidationPipe,
},
],
})
export class AppModule {}
提醒:全域 Pipe 的使用方法可以參考 DAY10 - Pipe (下)。
我會希望我們的 API 回傳格式式統一的,這對使用 API 的人來說是很重要的,而統一回傳格式這件事情最適合用 Interceptor 來實作了,直接將其配置在全域就可以套用到所有 API 上,十分方便!而我預期的格式如下,statusCode
即 HttpCode,oData
即回傳的資料:
{
"statusCode": 200,
"oData": {}
}
透過 CLI 快速產生一個 ResponseInterceptor
在 core/interceptors
資料夾底下:
$ nest generate interceptor core/interceptors/response
接著,運用 RxJS 的 pipe
與 map
來達到格式統一的效果:
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { map, Observable } from 'rxjs';
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const handler = next.handle();
return handler.pipe(
map((data) => {
const response = context.switchToHttp().getResponse();
return {
statusCode: response.statusCode,
oData: data,
};
}),
);
}
}
建立 index.ts
來做匯出管理:
export { ResponseInterceptor } from './response.interceptor';
最後,只需要在 AppModule
透過自訂 Provider 的方式進行全域配置即可:
import { APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { ResponseInterceptor } from './core/interceptors';
import mongoConfigFactory from './configs/mongo.config';
import secretConfigFactory from './configs/secret.config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [mongoConfigFactory, secretConfigFactory],
}),
MongooseModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.get<string>('mongo.uri'),
useFindAndModify: false,
}),
}),
],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: ResponseInterceptor,
},
{
provide: APP_PIPE,
useClass: ValidationPipe,
},
],
})
export class AppModule {}
提醒:Interceptor 的使用方法可以參考 DAY12 - Interceptor。
我希望我們設計的 API 都可以用 /api
作為路由前綴,但又不想要設計一個 ApiController
,這時候可以直接在 main.ts
使用 app.setGlobalPrefix('api')
來達到我們要的效果:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
await app.listen(3000);
}
bootstrap();
在設計 API 前,我們先把要存入 MongoDB 的資料設計好 Schema,好讓我們之後可以使用 Model 來操作資料庫,以這次要設計的系統來說,共需要設計兩個 Schema,分別為:user
與 todo
。
提醒:Schema 的設計方法可以參考 DAY22 - MongoDB。
這個專案所需的使用者資訊不必太多,只需要下方幾項即可:
username
:使用者名稱,必填欄位,最小長度 6
、最大長度 16
。email
:電子信箱,必填欄位。password
:密碼,必填欄位,最小長度 8
、最大長度 20
。role
:角色,必填欄位,接受的值為:admin
、manager
以及 member
,預設值為 member
。在開始設計 UserSchema
之前,可以先將欄位的最大值、最小值、角色列表設計成常數與列舉,這樣在其他地方也能夠使用相同的限制條件。在 common/constants
資料夾下建立一個 user.const.ts
:
export const USER_USERNAME_MIN_LEN = 6; // username 最小長度
export const USER_USERNAME_MAX_LEN = 16; // username 最大長度
export const USER_PASSWORD_MIN_LEN = 8; // password 最小長度
export const USER_PASSWORD_MAX_LEN = 20; // password 最大長度
接著,我們把角色列表做成列舉,在 common/enums
資料夾下新增 role.enum.ts
:
export enum Role {
ADMIN = 'admin',
MANAGER = 'manager',
MEMBER = 'member',
}
最後,就是來設計我們的 UserSchema
,在 common/models
資料夾下建立 user.schema.ts
:
import {
ModelDefinition,
Prop,
raw,
Schema,
SchemaFactory,
} from '@nestjs/mongoose';
import { Document } from 'mongoose';
import {
USER_USERNAME_MAX_LEN,
USER_USERNAME_MIN_LEN,
} from '../constants/user.const';
import { Role } from '../enums/role.enum';
export type UserDocument = User & Document;
@Schema({ versionKey: false })
export class User {
@Prop({
required: true,
minlength: USER_USERNAME_MIN_LEN,
maxlength: USER_USERNAME_MAX_LEN,
})
username: string;
@Prop({
required: true,
})
email: string;
@Prop({
required: true,
type: raw({
hash: String,
salt: String,
}),
})
password: { hash: string; salt: string };
@Prop({
required: true,
enum: Role,
default: Role.MEMBER,
})
role: Role;
}
export const UserSchema = SchemaFactory.createForClass(User);
export const USER_MODEL_TOKEN = User.name;
export const UserDefinition: ModelDefinition = {
name: USER_MODEL_TOKEN,
schema: UserSchema,
};
會發現 password
並沒有用到我們定義好的限制條件,原因是存入資料庫的是 hash
與 salt
,這個限制條件會放在 DTO 來做資料檢驗。
提醒:鹽加密的技巧可以參考 DAY23 - Authentication (上)。
以下為待辦事項所需的欄位:
title
:待辦事項的標題,必填欄位,最小長度 3
、最大長度 20
。description
:待辦事項的詳細描述,選填欄位,最大長度 200
。completed
:是否完成該待辦事項,必填欄位,預設為 false
。將限制條件設計為常數,在 common/constants
資料夾下新增 todo.const.ts
:
export const TODO_TITLE_MIN_LEN = 3; // title 最小長度
export const TODO_TITLE_MAX_LEN = 20; // title 最大長度
export const TODO_DESCRIPTION_MAX_LEN = 200; // description 最大長度
最後,在 common/models
資料夾下新增 todo.model.ts
:
import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
import {
TODO_DESCRIPTION_MAX_LEN,
TODO_TITLE_MAX_LEN,
TODO_TITLE_MIN_LEN,
} from '../constants/todo.const';
export type TodoDocument = Todo & Document;
@Schema({ versionKey: false })
export class Todo {
@Prop({
required: true,
minlength: TODO_TITLE_MIN_LEN,
maxlength: TODO_TITLE_MAX_LEN,
})
title: string;
@Prop({
maxlength: TODO_DESCRIPTION_MAX_LEN,
})
description?: string;
@Prop({
required: true,
default: false,
})
completed: boolean;
}
export const TodoSchema = SchemaFactory.createForClass(Todo);
export const TODO_MODEL_TOKEN = Todo.name;
export const TodoDefinition: ModelDefinition = {
name: TODO_MODEL_TOKEN,
schema: TodoSchema,
};
今天先將一些基礎設施建立完畢,如:環境變數、MongoDB 的連線、Schema 的配置、統一回傳格式等,如此一來,後面的開發就可以基於這些東西繼續進行。下一篇就會開始設計 API 了,敬請期待!
賀!!!即將完賽! 感謝大大分享很實用的 NestJs 讓同為 Nodejs 的開發者學到很多
謝謝你的支持,能夠幫助到你我很高興